package org.xx.armory.spring5.security;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.logout.ForwardLogoutSuccessHandler;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.xx.armory.commons.ForLogging;
import org.xx.armory.commons.TextUtils;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;

import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;

/**
 * 配置WebSecurity。
 * <p>此配置类型依赖的Bean：</p>
 * <ul>
 * <li>如果指定了 {@link AuthenticationProvider} Bean，那么非管理员帐户将使用这个Bean来验证，允许定义多个该类型的Bean，依次进行验证。</li>
 * <li>如果指定了 {@link PersistentTokenRepository} Bean，那么启动 {@literal RememberMe} 功能，并使用这个Bean来持久保存登录凭据。</li>
 * </ul>
 * <p>相关优先级：</p>
 * <ul>
 * <li>{@literal securityExcludedResources} 优先级最高，被排除的资源不经过{@literal spring-security}处理。</li>
 * <li>{@literal securityAuthorityEntries} 优先级第二。</li>
 * <li>{@link org.springframework.boot.web.servlet.error.ErrorController} 优先级最低，如果访问一个不存在的路径，但是该路径被配置为需要登录，那么会在登录成功后提示页面不存在。</li>
 * </ul>
 */
public class WebSecurityConfig {
    /**
     * 管理员用户名。
     */
    public static final String USER_ADMIN = "admin";
    /**
     * 匿名用户名。
     */
    public static final String USER_ANONYMOUS = "anonymousUser";

    /**
     * 管理员角色名。
     */
    public static final String ROLE_ADMIN = "ROLE_ADMIN";

    /**
     * 匿名角色名。
     */
    public static final String ROLE_ANONYMOUS = "ROLE_ANONYMOUS";

    /**
     * 一般角色名。
     */
    public static final String ROLE_BASIC = "ROLE_BASIC";

    @ForLogging
    private final Logger logger = LoggerFactory.getLogger(WebSecurityConfig.class);

    @Value("${armory.security.jump-action-401:none}")
    private JumpAction jumpAction401;

    @Value("${armory.security.jump-action-403:none}")
    private JumpAction jumpAction403;

    private void onAuthenticationRequired(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException authException
    )
            throws IOException, ServletException {
        switch (this.jumpAction401) {
            case Home:
                response.sendRedirect(request.getContextPath() + "/");
                break;
            case SignIn:
                response.sendRedirect(request.getContextPath() + "/sign-in");
                break;
            default:
                response.sendError(SC_UNAUTHORIZED);
        }
    }

    private void onAccessDenied(
            HttpServletRequest request,
            HttpServletResponse response,
            AccessDeniedException accessDeniedException
    )
            throws IOException,
            ServletException {
        switch (this.jumpAction403) {
            case Home:
                response.sendRedirect(request.getContextPath() + "/");
                break;
            case SignIn:
                response.sendRedirect(request.getContextPath() + "/sign-in");
                break;
            default:
                response.sendError(SC_FORBIDDEN);
        }
    }


    /**
     * 设置忽略的资源集合。
     *
     * @param excludedResources
     *         忽略的资源集合，访问这些资源不通过 {@literal Spring Security} 。
     * @return 用于设置忽略的资源集合的 {@link WebSecurityCustomizer}。
     */
    @Bean
    public WebSecurityCustomizer excludeResourcesSecurityCustomizer(
            @Autowired(required = false)
            @Qualifier("securityExcludedResources")
                    Collection<String> excludedResources
    ) {
        final var defaultExcludedResources = Set.of("/error", "/**/api-docs");
        final var allExcludedResources = new LinkedHashSet<>(defaultExcludedResources);

        if (excludedResources != null) {
            allExcludedResources.addAll(excludedResources);
        }

        logger.info("Exclude {} security resource(s)", allExcludedResources.size());

        // 忽略所有的静态资源、错误处理、调试工具和API文档。
        // 这些资源可以任意访问。
        return web -> web.ignoring()
                         .requestMatchers(PathRequest.toStaticResources().atCommonLocations())
                         .antMatchers(allExcludedResources.stream().map(CharSequence::toString).toArray(String[]::new));
    }


    /**
     * 执行全局配置。
     *
     * <p>示例代码：</p>
     * <pre>
     * super.configureGlobal(auth);
     * auth.inMemoryAuthentication();
     * auth.userDetailsService(new MyUserDetailService());
     * </pre>
     *
     * @param auth
     *         用于配置身份认证提供程序的构造类。
     * @param authenticationProviders
     *         用于进行身份认证的提供程序。
     * @throws Exception
     *         如果执行全局配置时出现错误。
     */
    @Autowired
    public void configureGlobal(
            AuthenticationManagerBuilder auth,
            @Autowired(required = false) Collection<? extends AuthenticationProvider> authenticationProviders
    )
            throws Exception {
        logger.info("Configure global authentication manager");

        if (authenticationProviders != null && !authenticationProviders.isEmpty()) {
            for (final var authenticationProvider : authenticationProviders) {
                if (authenticationProvider == null) {
                    logger.warn("Skip null authentication provider");
                } else {
                    logger.debug("Add authentication provider: {}", authenticationProvider);
                    auth.authenticationProvider(authenticationProvider);
                }
            }
        }
    }

    private ExpressionUrlAuthorizationConfigurer<?>.MvcMatchersAuthorizedUrl mvcMatchers(
            ExpressionUrlAuthorizationConfigurer<?>.ExpressionInterceptUrlRegistry r,
            HttpMethod httpMethod,
            String path
    ) {
        if (httpMethod != null) {
            return r.mvcMatchers(httpMethod, path);
        } else {
            return r.mvcMatchers(path);
        }
    }

    @Bean
    public SecurityFilterChain filterChain(
            HttpSecurity http,
            @Autowired(required = false)
            @Qualifier("securityAuthorityEntries")
                    Collection<AuthorityEntry> authorityEntries,
            @Autowired(required = false)
                    PersistentTokenRepository tokenRepository
    )
            throws Exception {
        http
                .headers()
                .frameOptions().sameOrigin()
                .cacheControl().and()
                .xssProtection().disable()
                .and()
                .csrf().disable()
                .exceptionHandling()
                .authenticationEntryPoint(this::onAuthenticationRequired)
                .accessDeniedHandler(this::onAccessDenied)
                .and()
                .formLogin().loginPage("/sign-in").loginProcessingUrl("/sign-in").successForwardUrl("/signed-in").failureForwardUrl("/sign-in").permitAll()
                .authenticationDetailsSource(new ArmoryWebAuthenticationDetailsSource())
                .and()
                .logout().invalidateHttpSession(true)
                .logoutRequestMatcher(
                        new OrRequestMatcher(
                                // GET 或者 POST 请求都可以执行注销。
                                new AntPathRequestMatcher("/sign-out", "POST"),
                                new AntPathRequestMatcher("/sign-out", "GET")
                        )
                )
                .logoutSuccessHandler(new ForwardLogoutSuccessHandler("/signed-out")).permitAll()
                .and()
                .authorizeRequests(ar -> {
                    // @formatter:off
                    ar
                            .antMatchers("/admin/**").hasAuthority(ROLE_ADMIN)
                            .antMatchers("/diagnosis/**").permitAll()
                            .antMatchers("/user/current", "/user/is-admin").permitAll()
                            .antMatchers("/user/**").authenticated()
                            .antMatchers("/captcha-code-image").permitAll()
                            .antMatchers("/portal/**").permitAll()
                            .antMatchers("/help/**").permitAll()
                            .antMatchers("/error/**").denyAll();
                    // @formatter:on

                    final var arTable = new StringBuilder();
                    arTable.append("\n----- Authority entries -----\n");
                    if (authorityEntries != null) {
                        authorityEntries
                                .stream()
                                .filter(Objects::nonNull)
                                .forEach(entry -> {
                                    final var method = entry.getMethod();
                                    final var methodName = method != null ? method.toString() : "ALL";
                                    if (entry.isPublic()) {
                                        arTable.append(TextUtils.format("\tALL -> {} {}\n", methodName, entry.getPath()));
                                        mvcMatchers(ar, entry.getMethod(), entry.getPath()).permitAll();
                                    } else if (entry.isAnyAuthenticated()) {
                                        arTable.append(TextUtils.format("\tANY AUTHENTICATED -> {} {}\n", methodName, entry.getPath()));
                                        mvcMatchers(ar, entry.getMethod(), entry.getPath()).authenticated();
                                    } else if (entry.isSingle()) {
                                        arTable.append(TextUtils.format("\t({}) -> {} {}\n", entry.getAuthority(), methodName, entry.getPath()));
                                        mvcMatchers(ar, entry.getMethod(), entry.getPath()).hasAuthority(entry.getAuthority());
                                    } else if (entry.isMultiple()) {
                                        arTable.append(TextUtils.format("\t({}) -> {} {}\n", String.join(", ", entry.getAuthorities()), methodName, entry.getPath()));
                                        mvcMatchers(ar, entry.getMethod(), entry.getPath()).hasAnyAuthority(entry.getAuthorities());
                                    } else if (entry.isEmpty()) {
                                        arTable.append(TextUtils.format("\tNONE -> {} {}\n", methodName, entry.getPath()));
                                        mvcMatchers(ar, entry.getMethod(), entry.getPath()).denyAll();
                                    }
                                });
                    }
                    arTable.append("-----------------------------");
                    logger.trace(arTable.toString());

                    ar.anyRequest().authenticated();
                });

        if (tokenRepository != null) {
            http.rememberMe().tokenRepository(tokenRepository);
        }

        return http.build();
    }
}
